Spring Cache 알아보기 - 2 (redis편)

2024년 08월 24일 17:00:00


목차

  • No items found

개발을 하며 알게 된 것들을 개인적으로 적고 공유 하는 거라 내용이 많이 틀릴 수 있습니다.

이번에는 스프링부트에서 redis를 이용하여 CacheManager를 적용하며 여러가지 정보들을 정리하려고 합니다.

CacheManager

1편에서 간단히 이야기하였던 추상화된 캐시를 사용하기 위해 스프링에서 제공하는것이 Cache Manager입니다.

다양한 캐시매니저가 있는데 여기서는 redis를 이용하도록 하겠습니다.

스프링 Data Redis 의존성 추가

https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis/3.3.3 Maven 저장소로 갑시다. (작성시점 스프링부트 버전임)

Gradle 의존성 추가
implementation("org.springframework.boot:spring-boot-starter-data-redis:3.3.3")

프로젝트의 build.gradle에 추가해줍시다.

CacheManager

추상화된 캐시를 사용하기 위해 스프링에서 제공하는것이 Cache Manager라고 했던것을 1편에서 이야기 했었죠.

인텔리지에서 구현체 검색했을때의 목록 1편에서는 RedisCacheManager가 없었는데 추가 된것을 확인할 수 있습니다.

Alt text

프로젝트 캐시매니저 설정에 redisCacheManager 추가하기

1편에서 로컬 캐시매니저로 CaffeineCacheManager를 추가했던 내용이 있을텐데 여기에 redisCacheManager를 추가해봅시다.

스프링에서는 여러 캐시 매니저도 어노테이션으로 쉽게 사용할 수 있게 구현되어있기 때문에 교체가 아닌 추가로 사용해봅시다.

저번과 다르게 뭔가 많은 코드들이 추가 된것 같아보이는데 설명과 함께 같이 보시죠.

@EnableCaching
@Configuration
class CacheConfig(
    private val redisProperties: RedisProperties,
) {
    companion object {
        const val DEFAULT_TTL = 5L
        const val CAFFEINE_CACHE_MANAGER = "caffeineCacheManager"
        const val REDIS_CACHE_MANAGER = "redisCacheManager"
        const val FINANCIAL_PRODUCTS = "financialProducts"
        const val FINANCIAL_PRODUCT = "financialProduct"
    }
 
    @Bean(name = [CAFFEINE_CACHE_MANAGER])
    fun caffeineCacheManager(): CacheManager =
        CaffeineCacheManager().apply {
            setCaffeine(
                Caffeine.newBuilder()
                    .initialCapacity(200)
                    .maximumSize(500)
                    .expireAfterWrite(JwtProvider.REFRESH_TOKEN_VALID_MILLISECONDS, TimeUnit.MILLISECONDS)
                    .weakKeys()
                    .recordStats(),
            )
        }
 
    @Primary
    @Bean(name = [REDIS_CACHE_MANAGER])
    fun redisCacheManager(
        redisConnectionFactory: RedisConnectionFactory,
        redisSerializer: RedisSerializer<Any>,
    ): CacheManager =
        RedisCacheManagerBuilder.fromCacheWriter(
            RedisCacheWriter.nonLockingRedisCacheWriter(
                redisConnectionFactory,
                UnlinkScanBatchStrategy(batchSize = redisProperties.batchSize),
            ),
        ).cacheDefaults(this.createRedisCacheConfiguration(redisSerializer = redisSerializer))
            .withInitialCacheConfigurations(
                mapOf(FINANCIAL_PRODUCTS to this.createRedisCacheConfiguration(redisSerializer = redisSerializer, redisTtl = 300L)),
            )
            .transactionAware()
            .build()
 
    @Bean
    fun redisSerializer(): RedisSerializer<Any> =
        GenericJackson2JsonRedisSerializer(
            ObjectMapper()
                .registerModules(
                    JavaTimeModule(),
                    KotlinModule.Builder().build(),
                )
                .setSerializationInclusion(JsonInclude.Include.ALWAYS)
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
                .activateDefaultTyping(
                    BasicPolymorphicTypeValidator.builder().allowIfSubType(Any::class.java).build(),
                    ObjectMapper.DefaultTyping.EVERYTHING,
                    JsonTypeInfo.As.PROPERTY,
                ).enable(SerializationFeature.INDENT_OUTPUT),
        )
 
    @Bean
    fun redisConnectionFactory(): LettuceConnectionFactory =
        LettuceConnectionFactory(
            RedisStandaloneConfiguration(redisProperties.host, redisProperties.port).apply {
                database = redisProperties.database
            },
            LettuceClientConfiguration.builder().commandTimeout(Duration.ofMillis(redisProperties.commandTimeout)).build(),
        )
 
    @Bean
    fun redisTemplate(
        connectionFactory: LettuceConnectionFactory,
        redisSerializer: RedisSerializer<Any>,
    ): RedisTemplate<String, Any> {
        val keySerializer = StringRedisSerializer()
        return RedisTemplate<String, Any>().apply {
            setConnectionFactory(connectionFactory)
            setKeySerializer(keySerializer)
            setValueSerializer(redisSerializer)
        }
    }
 
    private fun createRedisCacheConfiguration(
        redisSerializer: RedisSerializer<Any>,
        redisTtl: Long = DEFAULT_TTL,
    ): RedisCacheConfiguration =
        RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(redisTtl))
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
}
 

redisCacheManagerBuilder를 이용하여 구현체 등록하기

RedisCacheManager.class 내부를 보시면 RedisCacheManager 구현체를 생성하는데에는 여러 함수들을 제공해주고 있습니다.

그중에서 저희는 RedisCacheWriter를 커스텀하게 작성하기 위해 fromCacheWriter함수를 이용해서 등록하려합니다. (따로 커스텀이 필요하지 않다면 fromConnectionFactory를 사용하면됩니다.)

CacheWriter를 커스텀하게 등록하려는 이유는 cache객체에서 사용되는 clear를 커스터마이징하려합니다.

캐시 인터페이스를 보면 사용자에게 다양한 기능들을 제공해주고 있습니다. 각각의 메서드 이름에서 느껴지듯이 조회, 등록, 삭제등을 지원해주고 있습니다.

public interface Cache {
    String getName();
 
    Object getNativeCache();
 
    @Nullable
    ValueWrapper get(Object key);
 
    @Nullable
    <T> T get(Object key, @Nullable Class<T> type);
 
    @Nullable
    <T> T get(Object key, Callable<T> valueLoader);
 
    @Nullable
    default CompletableFuture<?> retrieve(Object key) {
        throw new UnsupportedOperationException(this.getClass().getName() + " does not support CompletableFuture-based retrieval");
    }
 
    default <T> CompletableFuture<T> retrieve(Object key, Supplier<CompletableFuture<T>> valueLoader) {
        throw new UnsupportedOperationException(this.getClass().getName() + " does not support CompletableFuture-based retrieval");
    }
 
    void put(Object key, @Nullable Object value);
 
    @Nullable
    default ValueWrapper putIfAbsent(Object key, @Nullable Object value) {
        ValueWrapper existingValue = this.get(key);
        if (existingValue == null) {
            this.put(key, value);
        }
 
        return existingValue;
    }
 
    void evict(Object key);
 
    default boolean evictIfPresent(Object key) {
        this.evict(key);
        return false;
    }
 
    void clear();
 
    // 이하 생략
}
 

그런데 여기서 아무 생각없이 사용해도 되지만 redis구현체는 어떤 방법으로 clear하는지 궁금하기도하고 성능상의 이슈는 없을까? 하는 고민이 있어 내부를 확인해보았습니다.

public void clear() {
    this.clear("*");
}
 
public void clear(String keyPattern) {
    this.getCacheWriter().clean(this.getName(), this.createAndConvertCacheKey(keyPattern));
}

보면 CacheWriter의 clean을 또 호출하는것을 볼 수 있습니다. 그럼 코드를 또 확인해봅시다.

public void clean(String name, byte[] pattern) {
    Assert.notNull(name, "Name must not be null");
    Assert.notNull(pattern, "Pattern must not be null");
    this.execute(name, (connection) -> {
        boolean wasLocked = false;
 
        try {
            if (this.isLockingCacheWriter()) {
                this.doLock(name, name, pattern, connection);
                wasLocked = true;
            }
 
            long deleteCount;
            for(deleteCount = this.batchStrategy.cleanCache(connection, name, pattern); deleteCount > 2147483647L; deleteCount -= 2147483647L) {
                this.statistics.incDeletesBy(name, Integer.MAX_VALUE);
            }
 
            this.statistics.incDeletesBy(name, (int)deleteCount);
            return "OK";
        } finally {
            if (wasLocked && this.isLockingCacheWriter()) {
                this.doUnlock(name, connection);
            }
 
        }
    });
}
 

코드를 확인해보면 this.isLockingCacheWriter()에서 lock, unlock인지 확인하고 locking이면 lock을 걸고 있네요.

일단 저는 락까지 걸면서 지워야할 데이터는 아니기에 nonLockingRedisCacheWriter을 이용하여 생성하였습니다.

public static RedisCacheManagerBuilder fromConnectionFactory(RedisConnectionFactory connectionFactory) {
    Assert.notNull(connectionFactory, "ConnectionFactory must not be null");
    RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory);
    return new RedisCacheManagerBuilder(cacheWriter);
}

이걸 따로 신경쓰지 않고 fromConnectionFactory를 이용하여 생성하면 기본적으론 nonLockingRedisCacheWriter가 사용되는것을 알 수 있습니다.

아니 그럼 여기서 든 의문이 뭐 이미 락도 안걸고 잘 지우고 있는걸 사용하고 있는것 같구만 굳이 커스터마이징 해야해? 라고 생각 할 수 있죠

(예, 저도 처음엔 그렇게 생각했는데 혹시나 하는마음에 redis와 내부 구현체를 찾아보게 되었습니다.) 다시 DefaultRedisCacheWriter 구현체 내부를 보면 실질적으로 캐시를 삭제하는 부분은

this.batchStrategy.cleanCache(connection, name, pattern); 이부분 같아 보이네요 이름에서 느껴지는게 반복문을 돌면서 특정 사이즈만큼씩 삭제를 하는것 같습니다.

그렇게 cleanCache의 구현체를 확인하기 위해 눌러보면 아래같은 추상 클래스가 등장하게 됩니다.

public abstract class BatchStrategies {
    public static BatchStrategy keys() {
        return BatchStrategies.Keys.INSTANCE;
    }
 
    public static BatchStrategy scan(int batchSize) {
        Assert.isTrue(batchSize > 0, "Batch size must be greater than zero");
        return new Scan(batchSize);
    }
 
    private BatchStrategies() {
    }
 
    static class Keys implements BatchStrategy {
        static Keys INSTANCE = new Keys();
 
        Keys() {
        }
 
        public long cleanCache(RedisConnection connection, String name, byte[] pattern) {
            byte[][] keys = (byte[][])((Set)Optional.ofNullable(connection.keys(pattern)).orElse(Collections.emptySet())).toArray(new byte[0][]);
            if (keys.length > 0) {
                connection.del(keys);
            }
 
            return (long)keys.length;
        }
    }
 
    static class Scan implements BatchStrategy {
        private final int batchSize;
 
        Scan(int batchSize) {
            this.batchSize = batchSize;
        }
 
        public long cleanCache(RedisConnection connection, String name, byte[] pattern) {
            Cursor<byte[]> cursor = connection.scan(ScanOptions.scanOptions().count((long)this.batchSize).match(pattern).build());
            long count = 0L;
            PartitionIterator<byte[]> partitions = new PartitionIterator(cursor, this.batchSize);
 
            while(partitions.hasNext()) {
                List<byte[]> keys = partitions.next();
                count += (long)keys.size();
                if (keys.size() > 0) {
                    connection.del((byte[][])keys.toArray(new byte[0][]));
                }
            }
 
            return count;
        }
    }
 
    static class PartitionIterator<T> implements Iterator<List<T>> {
        private final Iterator<T> iterator;
        private final int size;
 
        PartitionIterator(Iterator<T> iterator, int size) {
            this.iterator = iterator;
            this.size = size;
        }
 
        public boolean hasNext() {
            return this.iterator.hasNext();
        }
 
        public List<T> next() {
            if (!this.hasNext()) {
                throw new NoSuchElementException();
            } else {
                List<T> list = new ArrayList(this.size);
 
                while(list.size() < this.size && this.iterator.hasNext()) {
                    list.add(this.iterator.next());
                }
 
                return list;
            }
        }
    }
}

일단 함수내에 2가지 구현체가 있네요 Scan과 Keys 간단히 설명하자면 아래와 같아요.

KEYS 명령어는 패턴과 일치하는 모든 키를 한 번에 반환합니다. 예를 들어, KEYS user:*는 "user:"로 시작하는 모든 키를 반환합니다.

장점

특정 패턴과 일치하는 모든 키를 빠르게 얻을 수 있습니다.

단점

성능 문제: Redis는 단일 스레드로 작동하기 때문에, KEYS 명령어가 실행되면 서버는 모든 키를 검색하고 결과를 반환할 때까지 다른 작업을 처리할 수 없습니다.

즉, 데이터베이스에 키가 많을 경우(수백만 개 이상), 이 명령어는 Redis 서버를 일시적으로 멈출 수 있습니다.

블로킹 문제: KEYS 명령어는 매우 비효율적이며, 운영 환경에서 사용하면 큰 부하를 유발할 수 있습니다. 특히 프로덕션 환경에서는 사용하지 않는 것이 좋습니다.

SCAN 명령어는 KEYS와 유사하게 패턴과 일치하는 키를 검색하지만, 모든 결과를 한 번에 반환하는 대신 반복적으로 호출하여 부분적으로 키를 반환합니다.

장점

비차단형: SCAN은 블로킹 작업이 아니므로 Redis 서버가 다른 작업을 수행하는 데 큰 영향을 주지 않습니다. 따라서 프로덕션 환경에서 안전하게 사용할 수 있습니다.

점진적 검색: SCAN은 결과를 작은 청크(chunk) 단위로 반환하기 때문에 서버 자원 소모가 KEYS보다 훨씬 적습니다. 이는 대량의 키를 처리할 때 특히 유용합니다.

단점

중복 키: SCAN을 사용하면 동일한 키가 여러 번 반환될 수 있으므로, 이러한 중복을 처리해야 합니다.

결과 완전성 보장 없음: SCAN은 호출할 때마다 다른 결과를 반환할 수 있으며, 모든 키가 반환될 때까지 반복적으로 호출해야 합니다.

요약

KEYS: 빠르지만 성능에 큰 영향을 미치며, 많은 키가 있는 환경에서는 주의해서 사용해야 합니다.

SCAN: 성능에 영향을 적게 미치고, 대량의 키를 검색할 때 적합하지만 중복 처리와 여러 번 호출이 필요합니다.

대충 생각해보면 운영환경에선 KEYS를 사용해서 대량으로 데이터를 지우게되면 블로킹이 되어 장애까지 일어날 수 있겠다는 생각이 풀풀 들지 않나요?

그래서 잘은 모르지만 클라우드 환경에서 제공하는 redis의 경우 KEYS명령어 자체를 막아놓은 경우도 있다고합니다.

어쨋든 그러면 nonLockingRedisCacheWriter은 어떤걸 사용하고 있을까?

    static RedisCacheWriter nonLockingRedisCacheWriter(RedisConnectionFactory connectionFactory) {
        return nonLockingRedisCacheWriter(connectionFactory, BatchStrategies.keys());
    }
 
    static RedisCacheWriter lockingRedisCacheWriter(RedisConnectionFactory connectionFactory) {
        return lockingRedisCacheWriter(connectionFactory, BatchStrategies.keys());
    }
 

짜잔 기본으로 적용되는 친구들은 무려 Keys를 사용하고 있습니다. (어디선가 듣기론 하위호환성을 위해서 Keys로 되어있다고하더군요.)

만약 엄청나게 많은 트래픽 및 redis를 빈번하게 clear를 호출하게 된다면 어떻게 될까요?? 큰일 날 것 같은 기분이 들어 RedisCacheWriter를 직접 등록하도록 작성하게 되었습니다.

여기서 또 생각된게 어쨋든 Scan을 이용하여 chunk단위로 삭제한다고해도 그 청크단위만큼 삭제시에 블로킹이 걸리는건 마찬가지인 것 같은데 더 좋은방법이 없나 찾게 되었습니다.

(물론 이정도만 적용하여도 대부분 문제는 발생하지 않을 것 같긴합니다.)

찾아보니 Unlink라는 명령어를 찾게 되었습니다. 다시 또 GPT 씨의 말을 빌리자면 아래와 같은 차이점이 있습니다.

UNLINK 명령어는 Redis에서 데이터를 삭제하는 방법 중 하나로, 특히 큰 데이터 구조나 많은 키를 삭제할 때 성능에 미치는 영향을 줄이기 위해 도입되었습니다.

이 명령어는 DEL 명령어와 유사하지만, 동작 방식에서 중요한 차이점이 있습니다.

요약

DEL: 동기적으로 키를 삭제하며, 큰 데이터 구조를 삭제할 때는 성능 저하를 유발할 수 있습니다.

UNLINK: 비동기적으로 키를 삭제하여 서버 성능에 미치는 영향을 최소화합니다.

어 그러면 스프링에서도 구현되어 있는게 있을까? 하여 찾아봤으나 구현체를 따로 찾지 못하여 직접 구현체를 만들게 되었습니다.

class UnlinkScanBatchStrategy(
    private val batchSize: Int,
) : BatchStrategy {
    override fun cleanCache(
        connection: RedisConnection,
        name: String,
        pattern: ByteArray,
    ): Long =
        connection.keyCommands().scan(ScanOptions.scanOptions().count(this.batchSize.toLong()).match(pattern).build()).asSequence()
            .chunked(this.batchSize)
            .map { keys ->
                connection.keyCommands().unlink(*keys.toTypedArray())
                keys.size.toLong()
            }.sum()
}
 

대부분 Scan 구현체 내부의 코드를 참고하여 커맨드만 unlink형태로 수정하였습니다.

그럼 이제 다시 redis CacheManager를 등록하는 부분으로 돌아와 봅시다.

RedisCacheManagerBuilder의 다양한 옵션들

제가 사용한 옵션 위주로 살펴 보자면 .cacheDefaults(this.createRedisCacheConfiguration(redisSerializer = redisSerializer))

 
    @Bean
    fun redisSerializer(): RedisSerializer<Any> =
        GenericJackson2JsonRedisSerializer(
            ObjectMapper()
                .registerModules(
                    JavaTimeModule(),
                    KotlinModule.Builder().build(),
                )
                .setSerializationInclusion(JsonInclude.Include.ALWAYS)
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
                .activateDefaultTyping(
                    BasicPolymorphicTypeValidator.builder().allowIfSubType(Any::class.java).build(),
                    ObjectMapper.DefaultTyping.EVERYTHING,
                    JsonTypeInfo.As.PROPERTY,
                ).enable(SerializationFeature.INDENT_OUTPUT),
        )
 
    private fun createRedisCacheConfiguration(
        redisSerializer: RedisSerializer<Any>,
        redisTtl: Long = DEFAULT_TTL,
    ): RedisCacheConfiguration =
        RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(redisTtl))
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))

key값과 value값을 어떤 형태로 직렬화, 역질렬화 할것인지 정의 하는 부분입니다.

다양한 구현체들이 존재하지만 여기서는 자주사용되는 몇가지만 알아보도록 합시다.

다시 GPT의 힘을 빌리자면

문자열 데이터를 UTF-8 형식으로 직렬화하고 역직렬화하는 데 사용됩니다.

장점

간단하고 직관적이며, Redis 명령어로 쉽게 읽고 쓸 수 있습니다. 키와 값이 문자열인 경우 적합합니다.

단점

문자열 형식 이외의 데이터를 저장할 때는 적합하지 않습니다.

Java의 기본 직렬화 메커니즘인 Serializable 인터페이스를 사용하여 객체를 직렬화하고 역직렬화합니다.

장점

Java 객체를 그대로 Redis에 저장할 수 있습니다. Java의 모든 직렬화 가능한 객체를 지원합니다.

단점

직렬화된 데이터가 다른 플랫폼에서는 이해할 수 없는 이진 데이터로 저장됩니다.

직렬화된 데이터의 크기가 크고, 역직렬화 성능이 낮을 수 있습니다.

역직렬화 시 클래스의 호환성 문제가 발생할 수 있습니다.

Jackson 라이브러리를 사용하여 객체를 JSON 형식으로 직렬화하고 역직렬화합니다.

장점

사람이 읽을 수 있는 JSON 형식으로 데이터를 저장합니다.

다른 시스템과의 호환성이 높고, JSON을 이해할 수 있는 다양한 언어와 도구에서 쉽게 처리할 수 있습니다.

타입 정보를 포함하여 복잡한 객체도 직렬화할 수 있습니다.

단점

JSON 직렬화 과정에서 속도와 크기 면에서 약간의 성능 저하가 발생할 수 있습니다.

역직렬화 시 정확한 클래스 정보를 알아야 합니다.

Jackson2JsonRedisSerializer와 유사하지만, 직렬화된 JSON에 클래스 정보가 포함되도록 하여, 역직렬화 시 해당 클래스를 자동으로 인식할 수 있게 설계되었습니다.

장점

객체의 클래스 정보를 유지하면서 JSON 형식으로 데이터를 저장합니다.

복잡한 객체 구조를 지원합니다.

단점

클래스 정보가 추가되기 때문에 JSON의 크기가 증가할 수 있습니다.

Jackson을 기반으로 하기 때문에 기본적인 성능 이슈는 Jackson2JsonRedisSerializer와 유사합니다.

저는 key값에는 StringRedisSerializer, value값에는 GenericJackson2JsonRedisSerializer를 이용하였습니다.

현재 설정대로 redis에 어떤식으로 값이 저장되는지 간단히 보면 아래처럼 저장되는것을 볼 수 있습니다.

{
  "@class" : "com.hjj.apiserver.domain.financial.FinancialProduct",
  "financialProductId" : 900,
  "joinRestriction" : [ "com.hjj.apiserver.domain.financial.JoinRestriction", "NO_RESTRICTION" ],
  "financialProductType" : [ "com.hjj.apiserver.domain.financial.FinancialProductType", "SAVINGS" ],
  "financialSubmitDay" : "202407030802",
  "financialCompany" : {
    "@class" : "com.hjj.apiserver.domain.financial.FinancialCompany",
    "financialCompanyId" : 47,
    "financialCompanyCode" : "0010453",
    "dclsMonth" : "202406",
    "financialGroupType" : [ "com.hjj.apiserver.domain.financial.FinancialGroupType", "SAVING_BANK" ]
  }
  // 이하 생략
}

다음으로

.withInitialCacheConfigurations(
                mapOf(FINANCIAL_PRODUCTS to this.createRedisCacheConfiguration(redisSerializer = redisSerializer, redisTtl = 300L)),
            )

이부분은 미리 특정 Cache는 TTL을 기본값이 아닌값으로 설정 할 수 있도록 작업한 부분입니다.

다음은 .transactionAware() 이부분인데 처음에 실수 했던부분이기도합니다.

redis설정 빌더에 설정이 가능하다는것을 모르고 처음에는 캐시매니저를 TransactionAwareCacheManagerProxy로 감싸주는 형태로 작업하였더니 withInitialCacheConfigurations에서 정의 해놓은 값들이 적용이 안되더라고요.

꼭 주의해서 사용하시기바랍니다.

기능은 트랜잭션과 함께 redis가 작동한다고 보시면돼요 간단하게 커밋이 성공했을때 -> 캐시에 적재 또는 삭제한다. 라고 생각하시면됩니다.

대략적인 redis CacheManager의 설정에 대해 알아보았는데요.

기본적인 사용하는부분은 어노테이션기반으로 사용하게 되면 1편에서 작성되어있어 그부분을 참고하시면 좋을 것 같습니다.

부족한 글 읽어주셔서 감사하며 잘못된 내용이 있거나 여러 질문은 언제든 환영합니다.


JJ

황재정

백엔드 개발자로 일하고 있습니다.

Github